查看原文
其他

如何实现图片的扭曲效果,窗帘效果及仿真水波纹效果,修图技术之瘦身瘦脸效果的实现(android-drawBitmapMesh)

汪栋邢 搜狐技术产品 2021-07-27

本文字数:10953

预计阅读时间:28分钟


注:标题所说的这几个效果都是依赖 android-drawBitmapMesh实现的

让我们先来看看 google中Android API 中对 drawBitmapMesh 方法的介绍:

这个方法的参数貌似很多, 讲讲几个比较重要的参数的意思:

1、bitmap : 将要扭曲的图像;

2、meshWidth:控制在横向上把该图像划成多少格;

3、meshHeight : 控制在纵向上把该图像划成多少格;

4、verts : 网格交叉点坐标数组,长度为(meshWidth + 1) * (meshHeight + 1) * 2 ;

5、vertOffset : 控制verts数组中从第几个数组元素开始才对bitmap进行扭曲。

Android 中的 drawBitmapMesh() 方法与操纵像素点来改变色彩的原理类似。只不过是把图像分成一个个的小块,然后通过改变每一个图像块来改变整个图像。来看看下面这张经典的图像对比:

demo1 瘦身瘦脸效果实现

图一(瘦瘦的我)

图二(有点胖胖的我)

如上图,我们将图像分割成若干个图像块,在图像上横纵方向各划分成 N-1 格,而这横纵分割线就交织成了N乘以N个点,而每个点的坐标将以x1,y1,x2,y2,···,xn,yn的形式保存在 verts 数组里。也就是说,verts 数组中每两个元素保存一个交织点的位置,第一个保存横坐标,第二个保存纵坐标。而 drawBitmapMesh() 方法改变图像的方式,就是通过改变这个 verts 数组里的元素的坐标值来重新定位对应的图像块的位置,从而达到图像效果处理的功能。从这里我们就可以看得出来,借用 Canvas.drawBitmapMesh() 方法可以实现各种图像形状的处理效果,只是实现起来比较复杂,关键在于计算、确定新的交叉点的坐标。

效果图如下:


方法代码实现

首先,我们将要修整的图片加载进来,然后获取其交叉点的坐标值,并将坐标值保存到 orig[] 数组中。其获取交叉点坐标的原理是通过循环遍历所有的交叉线,并按比例获取其坐标,代码如下:

    //将图像分成多少格
    private int WIDTH = 200;
    private int HEIGHT = 200;
    //交点坐标的个数
    private int COUNT = (WIDTH + 1) * (HEIGHT + 1);
    //用于保存COUNT的坐标
    //x0, y0, x1, y1......
    private float[] verts = new float[COUNT * 2];
    //用于保存原始的坐标
    private float[] orig = new float[COUNT * 2];

    private void initView() {
        int index = 0;
        //传入图片,可以动态添加
        Bitmap mBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.test00);
        float bmWidth = mBitmap.getWidth();
        float bmHeight = mBitmap.getHeight();

        for (int i = 0; i < HEIGHT + 1; i++) {
            float fy = bmHeight * i / HEIGHT;
            for (int j = 0; j < WIDTH + 1; j++) {
                float fx = bmWidth * j / WIDTH;
                //X轴坐标 放在偶数位
                verts[index * 2] = fx;
                orig[index * 2] = verts[index * 2];
                //Y轴坐标 放在奇数位
                verts[index * 2 + 1] = fy;
                orig[index * 2 + 1] = verts[index * 2 + 1];
                index += 1;
            }
        }
    }
      //This is called during layout when the size of this view has changed. If
    //     * you were just added to the view hierarchy, you're called with the old
    //     * values of 0.
    //当会掉到onSizeChange时  重新 initView
      @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mWidth = w;
        mHeight = h;
        initView();
    }

传入bitmap 和对应的width和 height,返回 Bitmap

 private Bitmap zoomBitmap(Bitmap bitmap, int width, int height) {
        int w = bitmap.getWidth();
        int h = bitmap.getHeight();
        Matrix matrix = new Matrix();
        float scaleWidth = ((float) width / w);
        float scaleHeight = ((float) height / h);
        float scale = Math.min(scaleWidth,scaleHeight);
        matrix.postScale(scale, scale);
        return Bitmap.createBitmap(bitmap, 0, 0, w, h, matrix, true);
    }

然后就是将 verts[] 数组里面的坐标值进行一系列的自定义的修改。这里对 verts[] 数组的修改直接体现在图像的显示效果,各种图像特效的处理关键就在于此。比如这篇文章对 verts[] 数组的修改是实现图像局部约束变形效果。接着,我们将在onDraw()方法里,将修改过的 verts[] 数组重新绘制一遍,代码如下:

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawBitmapMesh(mBitmap, WIDTH, HEIGHT, verts, 0, null, 0, null);
    }

算法的代码实现 首先通过 onTouchEvent() 方法获取到触摸按下时的点 C 的坐标,以及拖动结束时的点 M 的坐标:

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                startX = event.getX();
                startY = event.getY();
                break;
            case MotionEvent.ACTION_UP:
                //调用warp方法根据触摸屏事件的坐标点来扭曲verts数组
                warp(startX, startY, event.getX(), event.getY());
                break;
        }
        return true;
    }

定义一下我们局部变形的作用半径 rmax:

//作用范围半径
private int r = 100;

接着就是最关键的代码,这里是将圆形范围内的每一个交叉点的横纵坐标分别求出其逆变换的坐标,并将求得的值重新赋给这个交叉点,下面将算法转换成java代码:

    private void warp(float startX, float startY, float endX, float endY) {

        //计算拖动距离
        float ddPull = (endX - startX) * (endX - startX) + (endY - startY) * (endY - startY);
        float dPull = (float) Math.sqrt(ddPull);
        //文献中提到的算法,并不能很好的实现拖动距离 MC 越大变形效果越明显的功能,下面这行代码则是我对该算法的优化
        dPull = screenWidth - dPull >= 0.0001f ? screenWidth - dPull : 0.0001f;

        for (int i = 0; i < COUNT * 2; i += 2) {
            //计算每个坐标点与触摸点之间的距离
            float dx = verts[i] - startX;
            float dy = verts[i + 1] - startY;
            float dd = dx * dx + dy * dy;
            float d = (float) Math.sqrt(dd);

            //文献中提到的算法同样不能实现只有圆形选区内的图像才进行变形的功能,这里需要做一个距离的判断
            if (d < r) {
                //变形系数,扭曲度
                double e = (r * r - dd) * (r * r - dd) / ((r * r - dd + dPull * dPull) * (r * r - dd + dPull * dPull));
                double pullX = e * (endX - startX);
                double pullY = e * (endY - startY);
                verts[i] = (float) (verts[i] + pullX);
                verts[i + 1] = (float) (verts[i + 1] + pullY);
            }
        }
        invalidate();
    }

好了,代码写完了。说了半天,上图对比就是可以变瘦可以变胖,当然,本来P图就是个技术活。

最后,添加作用范围圆形的显示和瘦脸拖动方向的显示 在 onDraw() 方法里加上绘制圆形和直线的代码,如下:

    //是否显示变形圆圈
    private boolean showCircle;
    //是否显示变形方向
    private boolean showDirection;

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawBitmapMesh(mBitmap, WIDTH, HEIGHT, verts, 0, null, 0, null);
        if (showCircle) {
            canvas.drawCircle(startX, startY, r, circlePaint);
        }
        if (showDirection) {
            canvas.drawLine(startX, startY, moveX, moveY, directionPaint);
        }
    }

接着重新写写 onTouchEvent() 方法里的代码,在 MotionEvent.ACTION_DOWN 中绘制变形区域,在 MotionEvent.ACTION_MOVE 中绘制变形方向直线,在 MotionEvent.ACTION_UP 中 去掉变形区域和变形方向直线,代码如下:

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                //绘制变形区域
                startX = event.getX();
                startY = event.getY();
                showCircle = true;
                invalidate();
                break;
            case MotionEvent.ACTION_MOVE:
                //绘制变形方向
                moveX = event.getX();
                moveY = event.getY();
                showDirection = true;
                invalidate();
                break;
            case MotionEvent.ACTION_UP:
                showCircle = false;
                showDirection = false;

                //调用warp方法根据触摸屏事件的坐标点来扭曲verts数组
                warp(startX, startY, event.getX(), event.getY());
                break;
        }
        return true;
    }

添加一键复原的按钮 还记得上面提到最初获取分割图片的交叉点的坐标,我们将原始坐标保存在了 orig[] 数组中。这里,当我们点击复原按钮,我们就将 orig[] 数组的值赋给 verts[] 数组,然后重新绘制即可,很简单,添加一个接口监听即可,然后在 MainActivity 中调用一下,代码如下:

    /**
     * 一键恢复
     */
    public void resetView() {
        for (int i = 0; i < verts.length; i++) {
            verts[i] = orig[i];
        }
        onStepChangeListener.onStepChange(true);
        invalidate();
    }

    public void setOnStepChangeListener(IOnStepChangeListener onStepChangeListener) {
        this.onStepChangeListener = onStepChangeListener;
    }

    public interface IOnStepChangeListener {
        void onStepChange(boolean isEmpty);
    }

大家也可以继续完善:如 变形区域的动态设置,记录每一次变形的数组值用于撤销上一步操作,等等。同样的,这里不仅仅可以瘦脸,还可以瘦各种地方。如果需要做拉伸处理,只需要将 verts[] 数组里的元素做相应的处理即可。

然后在demo中实现效果如下:


实例2 窗帘效果及水波纹效果实现

目前的效果图:

项目难点主要就是这个阴影效果和水波纹效果 我下面把源码计算贴一下大家可以仔细研究研究,有不懂得可以随时联系我。

下面请看:硬件加速不支持drawBitmapMesh的colors绘制的情况下,在原bitmap的上层覆盖一个半透明带阴影的bitmap以实现阴影功能

private void setupMask(){
        if (!newApiFlag && bitmap != null) {

            // 硬件加速不支持drawBitmapMesh的colors绘制的情况下,在原bitmap的上层覆盖一个半透明带阴影的bitmap以实现阴影功能
            //when API level lower than 18,the arguments of drawBitmapMesh method won't work when hardware accelerate is activated,
            //so we cover a transparent layer on the top of the origin bitmap to create a shadow effect
            shadowMask =
                  Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888);
            Canvas maskCanvas = new Canvas(shadowMask);
            float singleWave = bitmap.getWidth() / bitmapWidth * 6.28F;
            int blockPerWave = (int) (singleWave / (bitmap.getWidth() / bitmapWidth));
            if (blockPerWave % 2 == 0)
                blockPerWave++;
            float offset =
                    (float) ((bitmap.getWidth() / singleWave - Math.floor(bitmap.getWidth()
                            / singleWave)) * singleWave) + singleWave / 2;
            int[] colors = new int[blockPerWave];
            float[] offsets = new float[blockPerWave];
            float perOffset = 1.0F / blockPerWave;
            int halfWave = (int) Math.floor((float) blockPerWave / 2.0F);
            int perAlpha = maxAlpha / (halfWave - 1);
            for (int i = -halfWave; i < halfWave + 1; i++) {
                int ii = halfWave - Math.abs(i);
                int iii = i + halfWave;
                colors[iii] =
                        (int) (perAlpha * Math.sin((float) ii / (float) blockPerWave * 3.14F)) << 24;
                offsets[iii] = perOffset * iii;
            }
            maskShader =
                    new LinearGradient(offset, 0, singleWave + offset, 0, colors, offsets,
                            Shader.TileMode.REPEAT);
            paint.setShader(maskShader);
            maskCanvas.drawRect(0, 0, bitmap.getWidth(), bitmap.getHeight(), paint);
            paint.setShader(null);

        }
    }

计算方法看,水波纹效果主要在onDraw中实现

实现流程分析:首先你要弄清楚,这个verts数组存储的是什么?比如 verts[0]和verts1,这两个相邻的元素其实表示的就是我们第一个点的x坐标和y坐标!知道这一点,你就知道为什么有21 * 21个点,以及为什么数组长度等于这个值 * 2!初始化部分也就懂了!

接着我们再来看看根据触摸事件计算verts数组元素的值的实现:获得触摸点的x,y坐标,拿这个值去减对应点的x,y只,计算出触摸点和每个坐标点的距离 然后计算所谓的扭曲度:80000 / ((float) (dd * d));扭曲度 >= 1的,直接让该坐标点指向这个触摸点,< 1的,则让各个顶点向触摸点发生偏移,然后再调用invalidate()重绘~

 @Override
    public void onDraw(Canvas canvas) {
        if(this.bitmap != null){
            int index = 0;
            float ratio = (float) touchX / (float) width;
            float gap = 60.0F * (direction == DIRECTION_LEFT ? ratio : (1 - ratio));
            int alpha = 0;
            for (int y = 0; y <= bitmapHeight; y++) {
                float fy = height / bitmapHeight * y;
                float longDisSide = touchY > height - touchY ? touchY : height - touchY;
                float longRatio = Math.abs(fy - touchY) / longDisSide;
                longRatio = interpolator.getInterpolation(longRatio);
                float realWidth = longRatio * (touchX - delayOffsetX);
                float xBlock = (float) width / (float) bitmapWidth;
                for (int x = 0; x <= bitmapWidth; x++) {
                    ratio = (touchX - realWidth) / (float) width;
                    switch(direction){
                        case DIRECTION_LEFT:
                            verts[index * 2] = (bitmapWidth - x) * xBlock * ratio + (x * xBlock);
                            break;
                        case DIRECTION_RIGHT:
                            verts[index * 2] =  x * xBlock * ratio;
                            break;
                    }
                    float realHeight = height - ((float) Math.sin(x * 0.5F - Math.PI) * gap + gap);

                    float offsetY = realHeight / bitmapHeight * y;
                    verts[index * 2 + 1] = (height - realHeight) / 2 + offsetY;
                    int color;
                    int channel = 255 - (int) (height - realHeight) * 2;
                    if (channel < 255) {
                        alpha = (int) ((255 - channel) / 120.0F * maxAlpha) * 4;
                    }
                    if (newApiFlag) {
                        channel = channel < 0 ? 0 : channel;
                        channel = channel > 255 ? 255 : channel;
                        color = 0xFF000000 | channel << 16 | channel << 8 | channel;

                        colors[index] = color;
                    }

                    index += 1;
                }
            }
            canvas.drawBitmapMesh(bitmap, bitmapWidth, bitmapHeight, verts, 0, colors, 0, null);
            if (!newApiFlag) {
                alpha = alpha > 255 ? 255 : alpha;
                alpha = alpha < 0 ? 0 : alpha;
                paint.setAlpha(alpha);
                canvas.drawBitmapMesh(shadowMask, bitmapWidth, bitmapHeight, verts, 0, null, 0, paint);
                paint.setAlpha(255);
            }
        }
    }

这次主要讲的是 drawBitmapMesh 在安卓中的应用, 像我们看到的红旗飘飘的效果,都可以用这个来实现。

最后再把思路梳理一下:1、确定划分网格数 ;2、通过划分的网格数,确定变化数组float[] verts与原始数组float[] origs存放坐标点,使用一个数组存xy坐标(偶数位为X坐标,奇数位为Y坐标);3、通过for循环获得所有坐标点;4、监听手势,改变verts[]数组值,刷新界面;5、在canvas上绘制改变后的Bitmap。

关于drawBitmapMesh()这个方法

drawBitmapMesh(Bitmap bitmap, int meshWidth, int meshHeight, float[] verts, int vertOffset, int[] colors, int colorOffset, Paint paint) 

先讲一下这个方法的工作原理:首先,它将一张图片分成很多个小块,meshWidth是X轴方向的块数,meshHeight是Y轴方向的块数。我们知道,将一条线段分为5段,就会产生6个端点(包括两头的端点),同样的道理,将图片X轴方向分为meshWidth块,X轴方向就会有meshWidth + 1个端点,将图片Y轴方向分为meshHeight块,Y轴方向就会有meshHeight + 1个端点,所以整张图片就会有 (meshWidth + 1)乘以(meshHeight + 1)个端点。然后,我们制定每个端点的坐标drawBitmapMesh()就根据这些坐标将图片上的点绘制到canvas上。

利用这个方法理论上是可以做出各种动画效果,如果能给出动画效果中的每个点的坐标的话。

public class MeshBitmap extends View{

        private final int WIDTH = 200;  //X轴方向的块数
        private final int HEIGHT = 200; //Y轴方向的块数
        private int COUNT = (WIDTH + 1) * (HEIGHT + 1);  //总的端点数
        private float[] verts = new float[COUNT * 2]; //储存所有端点的坐标值
        private float[] orig = new float[COUNT * 2];  //储存所有端点在图片上对应的原始坐标
        private Bitmap bitmap;
        private int time;
        private int period;
        private int amplitude;
        private long preTime;
        public MeshBitmap(Context context) {
                super(context);
                setFocusable(true);
                bitmap = BitmapFactory.decodeResource(context.getResources(), R.drawable.flag);
                float bitmapWidth = bitmap.getWidth();
                float bitmapHeight = bitmap.getHeight();

                int index = 0;
                //算出所有端点的原始坐标
                for(int y = 0; y <= HEIGHT; y++)
                {
                        float fy = bitmapHeight * y / HEIGHT;
                        for(int x = 0; x <= WIDTH; x++)
                        {
                                float fx = bitmapWidth * x / WIDTH;
                                orig[index * 2 + 0] = verts[index * 2 + 0] = fx;
                                orig[index * 2 + 1] = verts[index * 2 + 1] = fy + 100;
                                index += 1;
                        }
                }

                time = 0;                //记录绘制的时间
                period = 2000;        //周期为2000毫秒
                amplitude = 50;        //振幅为50px
                preTime = System.currentTimeMillis();
                setBackgroundColor(Color.WHITE);
        }

        @Override
        protected void onDraw(Canvas canvas)
        {
                long current = System.currentTimeMillis();
                time += (int) (current - preTime);        //算出两次绘制的时间差,并累加到time上
                preTime = current;
                time %= period;                        //让time对周期去余数,得到当前处于一个周期的时间
                flagWave();                                //计算每个顶点的坐标
                canvas.drawBitmapMesh(bitmap, WIDTH, HEIGHT, verts, 0, null, 0, null);
                invalidate();
        }
        private void flagWave(){
                for(int j = 0; j <= HEIGHT; j++){
                        for(int i = 0; i <= WIDTH ; i++){
                                //每个点的X轴坐标不变,Y轴坐标按照正弦波形变化,注意X坐标对应的索引值,这是最关键的代码
                                verts[(j * (WIDTH + 1) + i) * 2 + 0] += 0;
                                verts[(j * (WIDTH + 1) + i) * 2 + 1]] = orig[(j * WIDTH + i) * 2 + 1] +
                                                (float) Math.sin(i * 2.0 / WIDTH * 3.1416 + time * 2 * 3.1416 / period) * amplitude;
                        }
                }
        }
}

代码中特别要注意的有:private int COUNT = (WIDTH + 1) * (HEIGHT + 1); //总的端点数 verts[(j * (WIDTH + 1) + i) * 2 + 0] += 0; verts[(j * (WIDTH + 1) + i) * 2 + 1]] = orig[(j * WIDTH + i) * 2 + 1] + (float) Math.sin(i * 2.0 / WIDTH * 3.1416 + time * 2 * 3.1416 / period) * amplitude; 理论上,改端点坐标的值就能产生各种炫酷效果 在最后的 Math.sin(i * 2.0 / WIDTH * 3.1416 + time * 2 * 3.1416 / period)中的i * 2.0改为(i * 2.0 + j * 0.5)会更有立体感。

(如果有问题的请大家指正,apk上传不了,只用在一个activity中setContentView(new MeshBitmap(this))就行)

好了,就讲到这里了。

感谢文献&算法来源:http://www.gson.org/thesis/warping-thesis.pdf

加入搜狐技术作者天团

千元稿费等你来!

👈 戳这里!





也许你还想看

(▼点击文章标题或封面查看)

Android Jetpack之Navigation全面剖析

2020-11-19

【文末有惊喜!】带你深入理解不一样的 Flutter

2020-10-29

WorkManager流程分析和源码解析

2020-10-22

全面详细的java线程池解密,看我就够了!

2020-09-03

【周年福利Round3】Coroutines(协程)我是这样理解的!

2020-08-20


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存